Skip to content

feat(backup): file-hash incremental detection for heap and AO tables#98

Open
liang8283 wants to merge 2 commits into
apache:mainfrom
liang8283:feat/incremental-file-hash
Open

feat(backup): file-hash incremental detection for heap and AO tables#98
liang8283 wants to merge 2 commits into
apache:mainfrom
liang8283:feat/incremental-file-hash

Conversation

@liang8283
Copy link
Copy Markdown

Summary

Adds two independent, opt-in flags to gpbackup that let --incremental detect table-level changes by hashing physical-storage state rather than relying solely on AO modcount:

  • --heap-file-hash — hashes each heap table's per-segment data-file mtime+size (via pg_stat_file() after a CHECKPOINT), so unchanged heap tables can be skipped from incremental for the first time. Default behavior — always include heap — is preserved when the flag is absent.
  • --ao-file-hash — hashes the AO/AOCS table's pg_aoseg content rows (segno + eof + tupcount, deliberately excluding modcount). This gives partition-leaf change detection in GP5 (where parent modcount propagates across sibling partitions) and catches some modcount-doesn't-match-data edge cases. AOCS uses a Cloudberry-aware column set (vpinfo when !IsGPDB(), otherwise the GP6+ schema).

Both flags require --leaf-partition-data or --incremental (validated in validate.go).

Motivation

Two long-standing gaps in incremental backup:

  1. Heap tables had no change detection at all — every incremental backup re-copied every heap table, regardless of whether the data file changed. For deployments with a few large mostly-static heap tables, this dominates incremental wall-time and storage.
  2. AO modcount is sometimes wrong — on GP5-style storage, the parent partition's modcount is the sum across children, so a single child write makes every sibling appear changed. This PR ports the algorithm originally developed in cloudberry-fe/gpbackup-enhanced into Apache Cloudberry-backup with conventions cleaned up and lint compliance.

Approach

  • Catalog metadata for change detection lives in the TOCIncrementalEntries.AO already exists; this PR adds an optional Heap map and an optional FileHashMD5 field to AOEntry, both yaml:",omitempty". TOCs written without the new flags are bytewise identical to today's. Older gprestore reads new TOCs without issue (extra fields ignored).
  • Hash collection runs on a dedicated dbconn so a per-table query failure doesn't abort the backup transaction. The heap path also issues a CHECKPOINT first to flush dirty pages, so pg_stat_file mtime/size reflect data state and not just buffer state.
  • The heap path uses pg_relation_filepath(oid) rather than constructing the on-disk path manually — this avoids the very-easy-to-get-wrong pg_tblspc/<oid>/PG_<major>_<catver>/... path inside the plpgsql helper and is robust across PG/Cloudberry versions and tablespaces.
  • OID is passed to the plpgsql helper (not schema/table strings), so SQL escaping isn't a concern and identifiers with embedded quotes just work.
  • FilterTablesForIncremental branches independently on each flag. When neither flag is set, control flow is logically identical to the previous version (AO compared by modcount + DDL timestamp; heap unconditionally included).

Files changed

File Change
toc/toc.go Add HeapEntry; add IncrementalEntries.Heap and AOEntry.FileHashMD5 (both omitempty).
options/flag.go Add HEAP_FILE_HASH, AO_FILE_HASH constants and pflag registrations.
backup/queries_incremental.go New: ensureFileStatFunction, getHeapTables, getTableFileHash, getFileHashesForTables, GetAOContentHashes, getAOSegContentHash. plpgsql helper gp_toolkit.gpbackup_file_info(p_oid oid) uses pg_relation_filepath(oid) + pg_stat_file().
backup/wrappers.go backupIncrementalMetadata now optionally CHECKPOINTs and collects heap + AO content hashes.
backup/incremental.go FilterTablesForIncremental rewrite with independent AO/heap branches.
backup/validate.go Reject --heap-file-hash / --ao-file-hash without --leaf-partition-data or --incremental.
backup/incremental_test.go Move FilterTablesForIncremental call into JustBeforeEach so package-level cmdFlags is initialized first.

Backward compatibility

Fully compatible.

  • Without the new flags: behavior matches the prior release exactly. The default code path is reduced to the same comparison logic as the old FilterTablesForIncremental.
  • TOC schema additions are all yaml:",omitempty". Old TOCs deserialize into the new structs; new TOCs without these flags serialize bytewise-identically to the old format.
  • gp_toolkit.gpbackup_file_info is only created when --heap-file-hash is used; if a previous gpbackup version installed an older (text, text) signature, the setup path drops it first (DROP FUNCTION IF EXISTS ... (text, text)).

How tested

  • make build: 5 binaries built cleanly.
  • make lint: no new findings attributable to this change (pre-existing errcheck/unparam issues in unrelated files are unchanged).
  • make unit: all 13 Ginkgo suites pass.
  • make end_to_end: 162 / 196 active specs PASS / 0 regressions traceable to this change. The 34 failures are all environmental (gpbackman binary path, leftover test_queue/test_tablespace between specs, and one test that creates tables without USING heap on a cluster whose default access method is ao_row).
  • Targeted live e2e on Cloudberry 2.5.0 — mixed schema with heap, AO row, AOCS, and partitioned AO; full + mutate + incremental cycle with both flags. Verified: changed tables included, unchanged tables skipped, partition-leaf granularity correct (one leaf included, sibling skipped), restore round-trip row counts match across all tables.
  • Targeted live e2e on Synxdb 4.5.0-rc.3 with custom tablespace — additionally verified that heap tables in a non-default tablespace are correctly detected as changed/unchanged. This is the empirical proof that pg_relation_filepath() handles the tablespace path correctly — without it, custom-tablespace heap would silently break in incremental detection.

The e2e scripts used for verification are reproducible; happy to commit them into end_to_end/ as a follow-up if reviewers want a regression test for the feature.

Contributor's checklist

  • Commits are logical units (one feat, one follow-up fix from code review).
  • CI is expected to pass: rat-check, build_and_unit_test, smoke/unit/integration/end_to_end/s3_plugin_e2e/regression/scale.
  • Code follows existing style; gofmt / goimports clean.
  • Squash if reviewers prefer a single commit — happy to do either.
  • Signed-off-by not currently included; will amend if reviewers want DCO.

liang8283 and others added 2 commits May 12, 2026 12:33
Adds two independent flags to gpbackup that let incremental backups
detect table changes by file content rather than by AO modcount alone:

  --heap-file-hash
      Hashes each heap table's data-file mtime+size on every segment via
      pg_stat_file() (CHECKPOINT first), so unchanged heap tables can be
      skipped in incremental. Default behavior (always include heap) is
      preserved when the flag is absent.

  --ao-file-hash
      Hashes (segno, eof, tupcount) of each AO table's aoseg rows --
      deliberately excluding modcount, which on GP5 propagates across
      sibling partitions when only one leaf is modified. AOCS tables use
      a Cloudberry-aware column set: segno + tupcount + vpinfo when
      !IsGPDB(), or GP7+ schema otherwise. With the flag, partition-level
      change detection works correctly even on GP5-style modcount leaks.

Both flags require --leaf-partition-data or --incremental.

Implementation:

  toc/toc.go
    Extends IncrementalEntries with optional Heap map[string]HeapEntry,
    and AOEntry with optional FileHashMD5. Both yaml-omitempty so TOCs
    written without the flags remain bytewise identical to the old
    format and are readable by older binaries.

  backup/queries_incremental.go
    Adds ensureFileStatFunction (installs a plpgsql wrapper around
    pg_stat_file in gp_toolkit; pure SQL, no plpython), getTableFileHash,
    getHeapTableFQNs, GetAOContentHashes, getAOSegContentHash. Uses a
    dedicated dbconn so per-table query failures do not abort the backup
    transaction.

  backup/wrappers.go
    backupIncrementalMetadata: when --ao-file-hash is set, merges
    aoseg content hashes into the existing AOEntry map; when
    --heap-file-hash is set, CHECKPOINTs, then collects per-segment file
    hashes for heap tables into a new HeapEntry map.

  backup/incremental.go
    FilterTablesForIncremental now branches independently on the two
    flags. When neither is set, behavior is identical to the prior
    version (AO compared by modcount+DDL, heap unconditionally included).

  backup/validate.go
    Rejects --*-file-hash without --leaf-partition-data or --incremental.

  backup/incremental_test.go
    Moves the FilterTablesForIncremental call into JustBeforeEach so the
    new MustGetFlagBool reads happen after BeforeSuite initializes
    cmdFlags. (Previously the call ran during spec-tree construction.)

Verification:

  - make build / make lint / make unit: pass on the server
    (no new lint findings; 13 ginkgo unit suites green).
  - Targeted live-cluster e2e on Cloudberry 2.5.0: 4-binary install,
    full + incremental cycle with both flags, mixed schema (heap/ao_row/
    aocs/partitioned AO). Confirmed unchanged tables skipped, partition-
    level granularity (one leaf included, sibling skipped), restore
    round-trip row counts match across all tables.
  - make end_to_end (1232s, 196 of 221 specs ran): no regressions
    attributable to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to e24bc69. Two real bugs and two minor cleanups found in
code review of that commit:

1. (data loss) gp_toolkit.gpbackup_file_info hand-built the tablespace
   path as 'pg_tblspc/<tsp>/<dboid>/<rfn>', missing the mandatory
   'PG_<major>_<catver>' directory that PostgreSQL/Cloudberry put in
   the middle. pg_stat_file would fail for every heap table living in
   a custom tablespace; the EXCEPTION branch swallowed the error and
   returned the literal string '|0' (not empty). Because '|0' is not
   filtered by 'WHERE info <> '''', that constant became the table's
   hash on every backup, so any custom-tablespace heap table would
   appear unchanged forever and be silently skipped from every
   incremental backup -- losing any new rows.

2. (SQL injection / correctness) getTableFileHash interpolated FQN
   parts straight into the SQL via fmt.Sprintf('%s', '%s'). splitFQN
   stripped double quotes but left single quotes intact, so a table
   name with an embedded single quote (a legal pg identifier, e.g.
   "o'reilly") would break out of the string literal.

3. (clarity) getAOSegContentHash's default case said "GP7+ AOCS" but
   actually fires for GP6+. Comment fixed.

4. (consistency) HeapEntry.FileHashMD5 missing 'omitempty' that
   AOEntry.FileHashMD5 already had.

Fix for apache#1 and apache#2 is the same change: pass the table's OID through to
the plpgsql function, and let pg_relation_filepath() compute the
on-disk path. The built-in already handles all tablespace layouts
correctly across PG/Cloudberry versions, and oid interpolation is just
an integer literal so the SQL-string-escaping concern disappears. The
EXCEPTION block now returns true empty-string, so a hash failure on a
single table falls through to "include in incremental" instead of
poisoning the hash with a constant.

Implementation notes:

  backup/queries_incremental.go
    - gp_toolkit.gpbackup_file_info now takes (p_oid oid) and uses
      pg_relation_filepath(p_oid). The setup path drops any older
      (text, text) signature first so an in-place upgrade against a
      previous gpbackup installation cleans up cleanly. The duplicate-
      check query gained 'pronargs = 1' to distinguish from the old
      signature.
    - getHeapTableFQNs -> getHeapTables, returning []heapTable{Oid,FQN}.
    - getTableFileHash(hashConn, oid, fqn) -- oid interpolated as
      integer literal, fqn used only for log messages.
    - getFileHashesForTables takes []heapTable.
    - splitFQN removed.

  backup/wrappers.go
    - One-line callsite update.

  toc/toc.go
    - HeapEntry.FileHashMD5 gets omitempty for symmetry with AOEntry.

Verification:

  - make build / make unit on the server: green.
  - Targeted live cluster e2e (heap + ao_row + ao_column + partitioned
    AO, full + mutate + incremental + restore): every expected
    inclusion/exclusion matches, restored row counts match source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant